# forward-compat boilerplate
from __future__ import absolute_import
from __future__ import with_statement
__metaclass__ = type

# TODO: get messages from win32 error codes

import re
import os
import sys
import uuid
import pytz
import ldap
import datetime

from fenton import util
from fenton import insecure
from fenton import security

AD_ERR_AUTH_FAIL = 0x80070056
AD_ERR_POOR_PASSWORD = 0x800708c5
AD_ERR_ACCOUNT_LOCKED = 0x80070775
MS_MAX_TIMESTAMP = 9223372036854775807
MS_EPOCH = datetime.datetime(1601, 1, 1, tzinfo=pytz.UTC)

MSTSRE = re.compile('(\d\d\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d).*')



class AccountLocked(security.SecurityException):
    pass


class WindowsError(security.SecurityException):
    pass


class LdapUser:

    mail = altmail = None
    def __init__(self, conn, attrs):
        self.conn = conn
        self._attributes = attrs
        self.__dict__.update(attrs)
        #attrs = dict((k.lower(), v) for (k, v) in attrs.iteritems())
        #self.__dict__.update(attrs)
        #self.__attributes = attrs
        self.dn = self.distinguishedname
        self.password_stamp = self.pwdlastset
        self.account_expires = self.accountexpires

        self.disabled = False
        if self.password_expires:
            now = self.conn.now()
            ac_exp = self.account_expires
            pw_exp = self.password_expires
            no_expire = ADS_UF_DONT_EXPIRE_PASSWD in self.uac_flags
            self.disabled = (self.account_disabled
                             or (ac_exp and ac_exp < now)
                             or (pw_exp and pw_exp < now and not no_expire))

    @property
    def must_change_password(self):
        return (not self.password_stamp and
                ADS_UF_DONT_EXPIRE_PASSWD not in self.uac_flags)

    def set_password(self, password, reset=False):
        return self.conn.winagent.call(_set_password,
                                       self.conn.domain,
                                       self.dn,
                                       password,
                                       reset)

    def change_password(self, old, new):
        try:
            return self.conn.winagent.call(_change_password,
                                           self.conn.domain,
                                           self.username,
                                           old, new)
        except insecure.winerror, e:
            code, msg, detail, other = e.args[0]
            code = code & 0xffffffff
            subcode = detail and detail[5] & 0xffffffff
            if subcode == AD_ERR_AUTH_FAIL:
                raise security.BadPassword
            if subcode == AD_ERR_POOR_PASSWORD:
                raise security.PoorPassword
            if subcode == AD_ERR_ACCOUNT_LOCKED:
                raise AccountLocked
            raise WindowsError(code, msg, subcode)

    def __repr__(self):
        return '<LdapUser %s>' % (self.username)

    # can't remember what this is for
    # obviously it orders users by lastname then firstname
    # but I can't remember what for
    def __cmp__(self, other):
        return cmp(self.fullname.lower(), other.fullname.lower())

    username = property(lambda x:x.samaccountname.lower())

    @property
    def fullname(self):
        sn = self.sn.upper() if self.sn else ''
        gn = self.givenname if self.givenname else ''
        if sn and gn:
            return '%s, %s' % (sn, gn)
        else:
            return self.name


class _worker:
    def __init__(self, parent):
        self.parent = parent
        self.tz = parent.tz
        self.handle = ldap.initialize(self.url)
        self.bind(self.bind_dn, self.bind_pw)

    def __getattr__(self, name):
        return getattr(self.parent, name)

    def bind(self, dn, pw):
        self.handle.simple_bind_s(dn, pw)

    def unbind(self):
        self.handle.unbind_s()

    def __search(self, *args, **kw):
        return self.handle.search_s(*args, **kw)

    def _search_all(self, base, q, *a, **k):
        result = self.__search(base, ldap.SCOPE_SUBTREE, q, *a, **k)
        if not result:
            return []
        return [dict((k.lower(), v) for k, v in item[1].items())
                    for item in result]

    def _search_one(self, base, scope, q, *a, **k):
        result = self.__search(base, scope, q, *a, **k)
        if not result:
            return None
        return dict((k.lower(), v) for k, v in result[0][1].items())

    def get_base(self, q, *a, **k):
        return self._search_one(self.orgbase, ldap.SCOPE_SUBTREE, q, *a, **k)

    def get_root(self, q, *a, **k):
        return self._search_one(self.root, ldap.SCOPE_SUBTREE, q, *a, **k)

    def get_dn(self, username):
        q = 'samaccountname='+username
        result = self.__search(self.orgbase, ldap.SCOPE_SUBTREE, q, [''])
        if not result:
            return None
        return result[0][0]

    def authenticate(self, username, password):
        try:
            dn = self.get_dn(username)
            if not dn:
                raise security.NoUser
            self.bind(dn, password)
        except ldap.error, e:
            raise LdapFailure, LdapFailure(e), sys.exc_info()[2]

    def get_user(self, id, groups=True):
        info = self.get_attributes(id)
        if not info:
            return None
        user = LdapUser(self, info)
        user.groups = groups and self.get_groups(user) or None
        return user

    def get_groups(self, user, allgroups=False):
        if allgroups:
            get = self.get_root
        else:
            get = self.get_base

        groups = set()
        seen = set()
        chase = [x.lower() for x in user._attributes.get('memberof') or []]
        while chase:
            next = []
            for dn in chase:
                group = get('distinguishedname='+dn, ['samaccountname', 'memberof'])
                sam = group and group.get('samaccountname')
                sam = sam and sam[0] or None
                if sam:
                    seen.add(dn)
                    groups.add(sam.lower())
                    parents = [x.lower() for x in group.get('memberof') or []]
                    next.extend(p for p in parents if p not in seen)
            chase = next
        return groups

    def get_attributes(self, dn):
        if '=' not in dn:
            dn = self.get_dn(dn)
            if not dn:
                return None

        dn = dn.replace('(', '\\(').replace(')', '\\)')
        attrs = self.get_base('distinguishedname='+dn)
        if not attrs:
            return None

        self.parent.convert_types(attrs)

        uac = attrs['useraccountcontrol']
        uac_items = set()
        uac_flags = set()
        if uac:
            for k, d in UAC_FIELDS.items():
                if uac & d:
                    uac_items.add(k)
                    uac_flags.add(d)
            attrs['uac_items'] = uac_items
            attrs['uac_flags'] = uac_flags

        attrs['account_disabled'] = uac and bool(uac & ADS_UF_ACCOUNTDISABLE)
        no_expire = uac and bool(uac & ADS_UF_DONT_EXPIRE_PASSWD)
        now = self.parent.now()
        lastset = attrs.get('pwdlastset') # or None -> never set or must change
        expires = not no_expire and lastset and self.pw_max_age + lastset
        attrs['password_expires'] = expires
        attrs['password_ttl'] = expires and expires - now
        attrs['altmail'] = None

        alt = attrs.get('altrecipient')
        if alt:
            alt = self.get_base('distinguishedname='+alt[0])
            if alt and 'targetaddress' in alt:
                alt = alt['targetaddress'][0].replace('SMTP:', '')
            else:
                alt = None
            attrs['altmail'] = alt

        return attrs


class Problem(Exception):
    pass


class ServerDown(Problem):
    pass


def _set_password(domain, dn, password, reset):
    from win32com.client import GetObject
    lobj = GetObject('LDAP://%s' % dn)
    #wobj = GetObject('WinNT://%s/%s,user' % (domain, lobj.samaccountname))
    #wobj.SetPassword(password)
    lobj.SetPassword(password)
    if reset:
        lobj.pwdLastSet = 0
        lobj.setinfo()


# must use WinNT:// to get correct error response
def _change_password(domain, username, old, new):
    from win32com.client import GetObject
    obj = GetObject('WinNT://%s/%s,user' % (domain, username))
    obj.ChangePassword(old, new)


def _current_sessions():
    from win32com.client import GetObject
    sessions = GetObject('WinNT://lynx/LanManServer').Sessions()
    return [(s.Computer.lower(), s.Name.lower()) for s in sessions]


def lookup(host):
    import socket

    if host[0] == '[':
        return host, ''

    if host.startswith('192.168'):
        ip = host
        host = socket.gethostbyaddr(ip)
        if '.' in host[0]:
            host = host[0].split('.')[0]
        host = '(%s)' % host
        ip = ' ' + ip
    else:
        ip = '(%s)' % socket.gethostbyname(host)

    return ip, host


def domain_sessions(rq):
    sessions = rq.app.auth.winagent.call(_current_sessions)
    def f():
        for (host, user) in sessions:
            try:
                ip, host = lookup(host)
            except Exception, e:
                ip = host
                host = '-'
            yield ip, host, user.split('\\')[0]
    for (ip, host, user) in sorted(f()):
        print '%-16s %-20s %s' % (ip, host, user)




class Authenticator:

    lockout_duration = property(lambda x:x.domain_info['lockout_duration'])
    lockout_threshold = property(lambda x:x.domain_info['lockout_threshold'])
    pw_max_age = property(lambda x:x.domain_info['pw_max_age'])
    pw_min_len = property(lambda x:x.domain_info['pw_min_len'])
    pw_history_len = property(lambda x:x.domain_info['pw_history_len'])
    pw_properties = property(lambda x:x.domain_info['pw_properties'])

    string =lambda s,x:x[0].decode('UTF-8')
    strings = lambda s,xs: [x.decode('UTF-8') for x in xs]
    ident = lambda s,x: x
    just = lambda s,x: x[0]
    num = lambda s,x: int(x[0])
    guid = lambda s,x: uuid.UUID(bytes=x[0])

    def from_msinterval(self, x):
        return datetime.timedelta(microseconds=long(x)/10)

    def mstimestamp(self, x):
        x = long(x[0])
        if x == 0:
            return None
        if x == MS_MAX_TIMESTAMP:
            return datetime.datetime.max.replace(tzinfo=self.tz)
        return (MS_EPOCH + self.from_msinterval(x)).astimezone(self.tz)

    def mstimestring(self, x, r=MSTSRE):
        x = x[0]
        t = datetime.datetime(*map(int, r.match(x).groups()))
        return pytz.UTC.localize(t).astimezone(self.tz)

    def convert_types(self, attrs):
        for k, f in self._user_fields.items():
            v = attrs.get(k)
            attrs[k] = f(self, v) if v is not None else None

    _user_fields = dict(
        accountexpires = mstimestamp,
        badpasswordtime = mstimestamp,
        lastlogon = mstimestamp,
        lastlogontimestamp = mstimestamp,
        lockoutime = mstimestamp,
        pwdlastset = mstimestamp,
        badpwdcount = num,
        whenchanged = mstimestring,
        whencreated = mstimestring,
        useraccountcontrol = num,
        description = string,
        cn = string,
        displayname = string,
        givenname = string,
        name = string,
        distinguishedname = string,
        samaccountname = string,
        sn = string,
        userprincipalname = string,
        #c = string,
        #co = string,
        #company = string,
        #department = string,
        #directreports = strings,
        #homedirectory = string,
        #homedrive = string,
        #homemdb = string,
        #homemta = string,
        homephone = string,
        #l = string,
        mail = string,
        mailnickname = string,
        #manager = string,
        mobile = string,
        scriptpath = string,
        #showinaddressbook = strings,
        title = string,
        telephonenumber = string,
        #memberof = strings
    )


    def __init__(self, config):
        def get(k):
            return config['directory.'+k]
        self.config = config
        self.root = get('rootdn')
        self.domain = get('ntdomain')
        self.orgbase = get('orgbase')
        self.userbase = get('userbase')
        self.bind_dn = get('bind_dn')
        self.bind_pw = get('bind_pw')
        self.tz = config['tz']

        host = get('host')
        secret = get('secret')

        self.url = 'ldap://%s:%s' % (host, get('ldapport'))
        self.winagent = insecure.Client(secret, host, int(get('winport')))

    def connect(self):
        try:
            return _worker(self)
        except ldap.SERVER_DOWN:
            raise ServerDown

    def now(self):
        from fenton import timestamp
        return timestamp.localnow(self.tz)

    @util.lazyattr
    def domain_info(self):
        w = self.connect()
        info = w._search_one(self.root, ldap.SCOPE_BASE, 'objectclass=domain')
        if not info:
            raise Problem('no domain info')

        lockout_duration = info['lockoutduration'][0]
        lockout_window = info['lockoutobservationwindow'][0]
        lockout_threshold = info['lockoutthreshold'][0]
        pw_max_age = info['maxpwdage'][0]
        pw_min_len = info['minpwdlength'][0]
        pw_history_len = info['pwdhistorylength'][0]
        pw_properties = info['pwdproperties'][0]

        return dict(
            lockout_duration = self.from_msinterval(abs(int(lockout_duration))),
            lockout_window = self.from_msinterval(abs(int(lockout_window))),
            lockout_threshold = abs(int(lockout_threshold)),
            pw_max_age = self.from_msinterval(abs(int(pw_max_age))),
            pw_min_len = int(pw_min_len),
            pw_history_len = int(pw_history_len),
            pw_properties = int(pw_properties),
        )

    def __set_user_password(self, username, password, reset=False):
        return self.winagent.call(_set_user_password, self.domain, username, password, reset)

    def __change_user_password(self, username, old, new):
        try:
            return self.winagent.call(_change_user_password, self.domain, username, old, new)
        except insecure.winerror, e:
            code, msg, detail, other = e.args[0]
            code = code & 0xffffffff
            if not detail:
                raise
            subcode = detail[5] & 0xffffffff
            if subcode == AD_ERR_AUTH_FAIL:
                raise security.BadPassword
            if subcode == AD_ERR_POOR_PASSWORD:
                raise security.PoorPassword
            if subcode == AD_ERR_ACCOUNT_LOCKED:
                raise AccountLocked
            raise WindowsError(code, msg, detail)

    def get_user(self, username, password=None):
        w = self.connect()
        if password is not None:
            w.authenticate(username, password)
        return w.get_user(username)

    def iter_users(self):
        w = self.connect()
        dns = w._search_all(self.userbase,
                            'objectclass=user',
                            ['distinguishedname'])
        for dn in sorted(x['distinguishedname'][0] for x in dns):
            yield w.get_user(dn)



class LdapFailure(security.LoginFailed):
    def __init__(self, orig):
        self.msg = orig.args[0].get('desc')
        self.detail = orig.args[0].get('info')
        self.orig = orig
    def __str__(self):
        return self.msg


# userAccountControl flags (on user object)
ADS_UF_SCRIPT = 0x1
ADS_UF_ACCOUNTDISABLE = 0x2
ADS_UF_HOMEDIR_REQUIRED = 0x8
ADS_UF_LOCKOUT  = 0x10
ADS_UF_PASSWD_NOTREQD = 0x20
ADS_UF_PASSWD_CANT_CHANGE = 0x40
ADS_UF_ENCRYPTED_TEXT_PASSWORD_ALLOWED  = 0x80
ADS_UF_TEMP_DUPLICATE_ACCOUNT = 0x100
ADS_UF_NORMAL_ACCOUNT = 0x200
ADS_UF_INTERDOMAIN_TRUST_ACCOUNT  = 0x800
ADS_UF_WORKSTATION_TRUST_ACCOUNT  = 0x1000
ADS_UF_SERVER_TRUST_ACCOUNT = 0x2000
ADS_UF_DONT_EXPIRE_PASSWD = 0x10000
ADS_UF_MNS_LOGON_ACCOUNT  = 0x20000
ADS_UF_SMARTCARD_REQUIRED = 0x40000
ADS_UF_TRUSTED_FOR_DELEGATION = 0x80000
ADS_UF_NOT_DELEGATED  = 0x100000
ADS_UF_USE_DES_KEY_ONLY = 0x200000
ADS_UF_DONT_REQUIRE_PREAUTH = 0x400000
ADS_UF_PASSWORD_EXPIRED = 0x800000
ADS_UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION = 0x1000000

UAC_FIELDS = dict((k, eval(k)) for k in dir()
                  if k.startswith('ADS_UF_'))

# pwdProperties flags (on domain object)
DOMAIN_PASSWORD_COMPLEX = 1
DOMAIN_PASSWORD_NO_ANON_CHANGE = 2
DOMAIN_PASSWORD_NO_CLEAR_CHANGE = 4
DOMAIN_LOCKOUT_ADMINS = 8
DOMAIN_PASSWORD_STORE_CLEARTEXT = 16
DOMAIN_REFUSE_PASSWORD_CHANGE = 32

